Skip to content

fix(🧵): isolate WebGPU state across runtime reloads - #427

Closed
NikitaDudin wants to merge 3 commits into
wcandillon:mainfrom
NikitaDudin:fix/expo-updates-reload-lifecycle
Closed

fix(🧵): isolate WebGPU state across runtime reloads#427
NikitaDudin wants to merge 3 commits into
wcandillon:mainfrom
NikitaDudin:fix/expo-updates-reload-lifecycle

Conversation

@NikitaDudin

Copy link
Copy Markdown

Fix WebGPU runtime lifecycle across Expo Updates reloads

Summary

Fixes WebGPU becoming unusable after expo-updates.reloadAsync().

Applications use WebGPU when they need direct and deterministic control over rendering and image quality. Reloading an update should replace the JavaScript runtime without leaving native GPU resources attached to the previous runtime.

Previously, the React Native runtime could be destroyed while process-wide WebGPU state survived. Depending on timing, the new runtime could encounter stale contexts, surfaces, JSI objects, or asynchronous callbacks owned by the previous runtime. This could result in a lost or black canvas after reload and introduced lifetime and thread-safety risks around runtime teardown.

Root cause

An Expo Updates reload recreates the React Native runtime without restarting the application process.

Several WebGPU objects could live longer than the runtime that created them:

  • runtime contexts and CallInvoker instances;
  • cached JSI prototypes and Promise callbacks;
  • pending asynchronous GPU operations and event listeners;
  • manager and surface registry entries;
  • native view callbacks arriving after runtime invalidation.

These objects were not consistently associated with a specific runtime generation. A newly installed runtime could therefore observe state belonging to the previous one, while pending native work could still attempt to dispatch callbacks to an invalid runtime.

Key changes

Runtime session ownership

  • Introduces a unique RNWebGPUSession for every native module installation.
  • Associates managers, canvases, surface registry entries, and native views with their owning session.
  • Rejects callbacks and surface operations belonging to an inactive or replaced session.
  • Exposes the session identifier internally so native canvas views cannot attach to state created by a previous runtime.

Deterministic invalidation

  • Invalidates the WebGPU session before the React Native runtime is destroyed.
  • Cancels pending asynchronous tasks and prevents further Promise or event delivery.
  • Clears runtime-owned manager and surface state during module teardown.
  • Ensures a newly installed runtime always starts with fresh WebGPU state.

JSI and async lifetime safety

  • Keeps prototype caches and Promise state scoped to the owning JSI runtime instead of process-wide storage.
  • Prevents asynchronous completions from accessing a destroyed jsi::Runtime.
  • Routes callbacks through the owning runtime context and CallInvoker.
  • Makes task cancellation and callback removal explicit during invalidation.

Native surface stabilization

  • Makes Android and Apple surface attachment session-aware.
  • Ignores delayed view callbacks from obsolete runtime sessions.
  • Balances native surface ownership across attach, detach, replacement, and teardown paths.
  • Prevents a canvas created after reload from aliasing a surface or context from the previous runtime.

Stability guarantees

The lifecycle now follows a small set of explicit invariants:

  • JSI values never cross runtime sessions.
  • An inactive session cannot deliver async callbacks.
  • Surface entries cannot be reused by a different runtime session.
  • Runtime teardown cancels native work before releasing runtime-owned state.
  • Reloading produces the same clean WebGPU installation state as the initial application launch.

The public WebGPU API remains unchanged. The session identifier is an internal lifecycle detail used by the native bridge and canvas implementation.

Diagnostics

Adds a runtime reload diagnostic to the example application.

The diagnostic:

  • creates an adapter and device;
  • registers a device.lost callback;
  • starts pending asynchronous GPU operations;
  • triggers a React Native reload;
  • displays the current WebGPU session identifier.

After a healthy reload, reopening the diagnostic should show a new session identifier and successfully create a fresh adapter and device.

Verification

  • git diff --check — clean.
  • yarn workspace react-native-webgpu tsc — passed.
  • iOS Debug simulator build for the react-native-webgpu scheme — succeeded.
  • Android :app:assembleDebug -x lint -x test — succeeded.
  • Independent review focused on correctness, native lifetime, and thread safety found no actionable issues.

Not covered here: an on-device end-to-end run using an actual expo-updates.reloadAsync() update. The included diagnostic currently exercises the same React Native runtime invalidation lifecycle through DevSettings.reload; an Expo Updates smoke test is recommended before merge.

@wcandillon

Copy link
Copy Markdown
Owner

This is a great suggestion thank you! Now #426 which is ready to ship and fixes many substancials will create a lot of conflict with this PR but we can do it.
It looks like you have a reproducible example in apps/example? could we start by merging this first in a separate PR?

@wcandillon

Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

@NikitaDudin

Copy link
Copy Markdown
Author

Thanks! Yes — the example is the ReloadLifecycle diagnostic in apps/example. It starts pending WebGPU work and triggers DevSettings.reload() to recreate the React Native runtime, so it is a minimal lifecycle regression case rather than a full expo Updates.reloadAsync() E2E test.
I agree that it should be merged separately first. I’ll extract a version compatible with the current main into a small PR, without the sessionId display since that is introduced by #427. Then I’ll remove the example-only changes from #427.
I’ve also merged the latest main, including #426, into #427 and resolved the conflicts.

@wcandillon

Copy link
Copy Markdown
Owner

@NikitaDudin sorry I may have lost context on this. Is RN WebGPU still subject to crashed using hot reload and/or OTA? if yes, I would like to get a sense of how easily it can be reproduced?

@NikitaDudin

Copy link
Copy Markdown
Author

Thanks for checking. The original failure was not necessarily a crash. In our app, with Expo SDK 55 / RN 0.83 and react-native-webgpu 0.5.9, expo-updates.reloadAsync() recreated the JS runtime while the process-wide native WebGPU state survived. The static module manager could therefore remain associated with the old JSI runtime. After the reload, adapter/device acquisition or newly mounted canvases could stop working.
I initially fixed this locally by forcing the native manager to be recreated, cancelling pending callbacks, and clearing the surface registry. I kept porting that patch across react-native-webgpu versions, and it eventually became #427.
However, after moving to Expo SDK 57 / RN 0.86, I can no longer reproduce the visible failure. I retested with react-native-webgpu 0.5.11, 0.6.0, and 0.6.1, and WebGPU recovered successfully after reloadAsync(). Since 0.5.11 predates #426, the original symptom was probably resolved by changes in the Expo/RN runtime lifecycle. #426 separately fixed stale surface-registry aliasing and black-canvas scenarios.
So at the moment I do not have an easy, reliable crash reproduction, and I would not suggest merging this large PR solely as a fix for the original symptom.
The remaining motivation for #427 is lifecycle hardening. A reload may occur while mapAsync(), createComputePipelineAsync(), onSubmittedWorkDone(), device.lost, or error listeners are still pending. On current main, some runtime contexts, CallInvokers, Promise callbacks, and prototype caches may still outlive the JSI runtime that created them. #427 introduces runtime generations/sessions, rejects callbacks and native-view events from obsolete sessions, cancels runtime-owned async work during teardown, and scopes prototype caches to their JSI runtime.
Parts of that problem have since been addressed by #426 and #448, but they do not provide complete runtime-session invalidation. Therefore #427 may still prevent old-runtime “ghosting”, retained resources, or a late callback touching a destroyed runtime. Given its current size, the best path may be to extract the async cancellation/runtime invalidation portion into a smaller focused PR with a teardown stress test.

@kimchouard

Copy link
Copy Markdown

Re: your question about whether reload/OTA crashes still happen in the wild: yes. Filed #461 with a tombstone stack from an expo-updates OTA apply on Android (react-native-webgpu 0.5.15), SIGSEGV in NativeObject::installConstructor against the recreated runtime. Reproduces on 100% of OTA applies on that device, so we can test a patch build if useful.

@wcandillon

Copy link
Copy Markdown
Owner

@NikitaDudin @kimchouard I am testing a fix for this and of course RN Skia suffers from the same issue, thank you for reporting this.

@wcandillon

Copy link
Copy Markdown
Owner

closing in favor of #463 And I will ship another improvement on top of it afterwards. But please let me know if you have any issues.

@wcandillon wcandillon closed this Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants